Repository navigation
Refetch shipping on coupon application - #1441
pbennett1-godaddy wants to merge 38 commits into
Conversation
Task: task-2
Task: task-1
Task: task-4
Task: task-7
🦋 Changeset detectedLatest commit: 987d294 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Preserve tipping, VAT, billing, and payment updates alongside coupon shipping reconciliation. Keep initial shipping taxes and avoid duplicate fulfillment refreshes, with regression coverage.
wcole1-godaddy
left a comment
There was a problem hiding this comment.
Nice work splitting the core mutations from their workflow side effects. The single tax call is well covered, and the request-id guard in the express coupon sync is a good fix for the duplicate PriceAdjustments calls. CI-equivalent checks pass locally (818 tests, typecheck, biome).
I'm requesting changes for two shipping-selection regressions I reproduced against main, plus two express checkout issues. Details are inline. A few items didn't fit inline:
- Free-shipping filter removal: Is the free-shipping minimum order total enforced server-side in every environment? If not, removing the
experimental_rules.freeShippingfilter means free shipping is offered below the threshold. DroppingfreeShippingfrom the queries also removes it from the publicCheckoutSessiontype, which is derived from the query, in a patch release. - PR description: The description mentions express visibility changes ("without depending on temporary
PURCHASEfulfillment state", plus digital-only/pickup visibility coverage), but they aren't in the diff. Please update the description or add the missing changes. - Unrelated schema changes:
checkout-env.tsaddsorderIdand acheckoutSession(id:)arg. These look unrelated, so please split them out or confirm they're intended.
| const existingMethod = currentFormMethod || currentServiceCode; | ||
| const isInitialSelection = lastShippingMethodsKeyRef.current === null; | ||
| const { selectedMethod: methodToApply, methodsKey } = | ||
| selectShippingMethod({ |
There was a problem hiding this comment.
Regression: a persisted shipping selection is overwritten on load. On first render lastShippingMethodsKeyRef.current is null, so selectShippingMethod treats the rates as changed and picks the cheapest method. It then mutates the order if the saved line differs.
To reproduce, load a draft order with weight-based ($1) already selected and the default rates. main sends no ApplyCheckoutSessionShippingMethod; this branch applies free-shipping. This affects reloads, returning from redirect payment flows (MercadoPago/CCAvenue), and any remount of this form.
The free-order test was rewritten to expect this behavior, and the new "preserves an existing rate" test only uses the cheapest rate, so it can't catch it.
Suggested fix: on first load, seed the key from the current methods so an existing service code is kept, e.g. previousMethodsKey: lastShippingMethodsKeyRef.current ?? getShippingMethodsKey(shippingMethods) when existingMethod is set.
| const availableMethods = sortShippingMethods(shippingMethods); | ||
| const methodsKey = getShippingMethodsKey(availableMethods); | ||
| const methodsChanged = methodsKey !== previousMethodsKey; | ||
| const selectedMethod = methodsChanged |
There was a problem hiding this comment.
An explicit customer choice is downgraded whenever any rate in the list changes. The key includes cost, so any repricing counts as a change and falls back to availableMethods[0].
To reproduce, the customer picks Express ($20 vs Standard $5), then edits the postal code so the rates become $21 / $6. main keeps Express; this branch switches to Standard. Carrier rates reprice on almost every address edit, so this will be common. The same thing happens when a coupon makes a different method cheaper.
Is that intended? If not, I'd keep the current method whenever it's still available and the customer chose it. Only fall back to the cheapest when the current method is gone, was auto-defaulted, or free shipping newly appears. One way is to track user selection in handleValueChange.
| // Start with the base line items | ||
| const baseLineItems = [...poyntExpressRequest.lineItems]; | ||
|
|
||
| // Refetch shipping methods so rates reflect the coupon change (e.g. free-shipping discounts) |
There was a problem hiding this comment.
I don't think this refetch can reflect the coupon. DraftOrderShippingRatesQuery takes only destination, and a wallet-entered coupon is never written to the draft order. It only feeds the read-only calculatedAdjustments query. So the rates come back the same as before.
Meanwhile this adds a round trip on every coupon change. Combined with the block at ~L847, it replaces the wallet's shippingMethods while the line items and total still use godaddyTotals.shipping.value / shippingMethod. If the wallet resets the selection to the first (cheapest) option, the displayed method and the charged amount diverge. I'd remove this block and the one at ~L847.
| const defaultMethod = sortShippingMethods(shippingMethodsData || [])[0]; | ||
|
|
||
| if (defaultMethod) { | ||
| setSelectedShippingRate({ |
There was a problem hiding this comment.
This can mutate selectedShippingRate outside a wallet event. shippingAddress is reset in handleClick and handleCancel, so this branch only runs while the sheet is open. When it does run, it changes the selected rate without resolving anything to the Express Checkout Element. handleConfirm would then submit a shippingTotal and method different from what the customer saw. Since Stripe ECE has no coupon entry, I'd drop the shipping refetch from this effect entirely.
| syncPriceAdjustments(); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [draftOrder, draftOrderDiscountCodes]); | ||
| }, [hasDraftOrder, discountCodesKey, couponSyncRevision]); |
There was a problem hiding this comment.
Keying only on the discount codes removes the duplicate requests, but calculatedAdjustmentsRef depends on the subtotal and shipping too. If the cart or shipping changes while the codes stay the same, the cached adjustments are stale (e.g. a percentage discount computed on the old subtotal). Consider adding totals.subTotal / shippingTotal values to the key. The same applies to the Stripe effect.
| result?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates | ||
| ) | ||
| ) { | ||
| throw new Error('Shipping rates are unavailable'); |
There was a problem hiding this comment.
Two questions:
- Can the API return
calculatedShippingRates: nullfor a legitimate "no rates" case, e.g. a store with no shipping profile? If so, this now shows the failure/retry UI and clears the applied shipping instead of "No shipping methods found." - Since this is an intentional throw, please set
retry: falsehere explicitly. Hosts can pass their ownQueryClient, and TanStack's default of 3 retries means ~7s of skeleton, plususeReconcileAfterDiscountawaits the refetch while the coupon button spins.
| ); | ||
| } | ||
|
|
||
| const allCodes = new Set<string>(); |
There was a problem hiding this comment.
Pre-existing, but easy to fix now: this re-applies only order and shipping-line codes. The discount mutation replaces the whole list (DiscountStandalone sends the full set, and removal sends []), so an order with both an order-level code and a line-item code loses the line-item code on any shipping change. getDraftOrderDiscountCodes(order) would fix it. DiscountStandalone could use the same helper.
| }, | ||
| }, | ||
| ], | ||
| shippingLines: [], |
There was a problem hiding this comment.
This hook isn't used anywhere. It's still edited here, and it calls useDiscountApply, which now runs useReconcileAfterDiscount. That could re-apply a shipping method right after it's removed. I'd delete it, or switch it to useApplyDiscountCore.
|
|
||
| Fix billing collection, shipping reconciliation, and discount/coupon sync across checkout flows. | ||
|
|
||
| - Align billing fields and validation for paid, free, pickup, shipping, purchase, and digital orders. |
| 'Geben Sie Ihre Adresse ein, um verfügbare Versandmethoden zu sehen.', | ||
| noShippingMethods: 'Keine Versandmethoden gefunden.', | ||
| failedToLoadMethods: | ||
| 'Versandarten konnten nicht geladen werden. Bitte versuche es erneut.', |
There was a problem hiding this comment.
Nit: the rest of deDe uses formal address ("Sie"). Suggest 'Versandarten konnten nicht geladen werden. Bitte versuchen Sie es erneut.'
Keep a saved or customer-chosen shipping method while it is still offered, including on load, and only fall back to the cheapest rate when it is gone, was picked automatically and rates changed, or free shipping newly appears. Re-apply line-item discount codes on shipping changes, stop the Stripe express sheet from refetching and reselecting shipping on coupon changes, use the highest-value code for express wallets, and recompute cached coupon adjustments when the subtotal changes. Keep the unused remove-shipping hook from triggering reconciliation, disable automatic retries on the shipping rates query, trim the changeset, and use formal German copy. Add tests for the selection rules, failed-reapply recovery, discount cache matching, and the GoDaddy and Stripe express coupon sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wcole1-godaddy
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. Most of round one is resolved: the saved selection survives load, explicit choices survive repricing, Stripe no longer refetches shipping on coupon changes, retry: false is set, line-item codes are re-applied, the changeset is trimmed, and the deDe copy is formal. Typecheck, 840 tests, and biome pass locally.
I'm requesting changes for one new regression I reproduced against main (inline on shipping-method.tsx), plus a few round-one items that are still open. Two that don't fit inline:
- Free-shipping filter removal: the description now states the shipping API is authoritative. A link to the server-side
minimumOrderTotalenforcement would close this out. Also noteCheckoutSession['experimental_rules']losesfreeShippingin a patch release; the type is public viaexport * from './types'. checkout-env.ts: theorderIdfield andcheckoutSession(id:)argument are still in the diff with no comment. Please split them out or confirm they're intended for this change.
Non-blocking notes are inline as well.
| if (hasShippingMethods) { | ||
| const firstMethod = shippingMethods[0]; | ||
| const currentFormMethod = form.getValues('shippingMethod'); | ||
| const existingMethod = currentFormMethod || currentServiceCode; |
There was a problem hiding this comment.
Regression: infinite ApplyCheckoutSessionShippingMethod loop when an automatic selection is switched after an address edit.
Repro: fresh checkout with no saved shipping line (standard $5 is auto-selected), the customer edits the postal code, and the new rates are express $20 / standard $25. On main this sends exactly one mutation (keeps standard, repriced). On this branch the mutations alternate express → standard → express … indefinitely.
Mechanism (from instrumenting the effect):
- This effect runs
form.setValue('shippingMethod', 'express', { shouldDirty: false })andmutate(express). - In the same commit, the parent
CheckoutFormhydration effect runs with a staleisCheckoutBusy === false(the mutation only just started) and a draft-order snapshot whose line is stillstandard, soform.reset(...)writesshippingMethod = 'standard'. The value isn't dirty, sokeepDirtyValuesdoesn't protect it. - The mutation succeeds: server/cache =
express, form =standard. The rates haven't changed, soselectShippingMethodkeeps the "current" method, and this line picks the form value →mutate(standard)→ repeat.
main is immune because it never changes the method automatically, so the form and server never diverge non-dirtily. Explicit clicks are dirty and protected, which is why the "customer-chosen Express + reprice" test passes. The same loop triggers via the "free shipping newly appears" rule when the current method came from the server on load (also non-dirty).
Fix I validated locally (full checkout suite still passes, repro converges): an automatic selection should never outrank the order's shipping line.
const existingMethod = previousAutoSelected
? currentServiceCode || currentFormMethod
: currentFormMethod || currentServiceCode;Please also add a regression test; none of the current tests exercise an address-triggered switch. This is the one I used (it times out on the current branch because isMutating never settles, and passes with the fix):
it('moves an automatic selection to the new cheapest rate when the address reprices it', async () => {
const rates = (standard: number, express: number) =>
buildShippingRates([
{ serviceCode: 'standard', carrierCode: 'carrier', displayName: 'Standard', cost: { value: standard, currencyCode: 'USD' } },
{ serviceCode: 'express', carrierCode: 'carrier', displayName: 'Express', cost: { value: express, currencyCode: 'USD' } },
]);
const { user, queryClient } = renderCheckout({
apiOverrides: { shippingMethods: rates(500, 2000) },
draftOrderOverrides: { shippingLines: [] },
});
await waitForCheckoutReady();
await waitFor(() => expect(queryClient.isMutating()).toBe(0));
expect(screen.getByRole('radio', { name: /standard/i })).toBeChecked();
clearOperations();
setShippingMethods(rates(2500, 2000)); // express is now the cheapest
const postal = document.querySelector('input[name="shippingPostalCode"]') as HTMLInputElement;
await user.clear(postal);
await user.type(postal, '94016');
await advanceCheckoutDebounce();
await waitForOperation('UpdateCheckoutSessionDraftOrder');
await waitForOperation('DraftOrderShippingRates', 1, 6000);
await waitFor(() => {
expect(queryClient.isMutating()).toBe(0);
expect(queryClient.isFetching()).toBe(0);
});
await flushPromises();
const applied = getOperations('ApplyCheckoutSessionShippingMethod');
expect(applied.at(-1)?.input).toEqual([expect.objectContaining({ requestedService: 'express' })]);
expect(screen.getByRole('radio', { name: /express/i })).toBeChecked();
});| // Start with the base line items | ||
| const baseLineItems = [...poyntExpressRequest.lineItems]; | ||
|
|
||
| // Refetch shipping methods so rates reflect the coupon change (e.g. free-shipping discounts) |
There was a problem hiding this comment.
Still think this should go. The PR description cites checkout-api#207, but that change computes adjustedOrderTotal from discounts persisted on the draft order. A wallet-entered coupon is never persisted — it only feeds calculatedAdjustments, and close_wallet discards it — so this refetch returns the same rates as the shipping_address_change fetch both before and after #207. It's a guaranteed no-op round trip on every coupon change.
The risk from round one also stands: updateWith({ shippingMethods }) (~L851) replaces the wallet's list while lineItems/total in the same update are computed from the old godaddyTotals.shipping. If Poynt resets the selection to the first entry, the displayed method and the charged amount diverge.
Please remove this block and the one at ~L851, or confirm Poynt preserves the selected method on updateWith({ shippingMethods }).
| } | ||
| } | ||
|
|
||
| // Include the refreshed shipping methods so the wallet reflects rates for the current coupon state |
There was a problem hiding this comment.
See the comment at ~L549. This is the half that can desynchronise the wallet's selected method from godaddyTotals.shipping.
| result?.checkoutSession?.draftOrder?.calculatedShippingRates?.rates | ||
| ) | ||
| ) { | ||
| throw new Error('Shipping rates are unavailable'); |
There was a problem hiding this comment.
Round-one question still open: can the API return calculatedShippingRates: null for a legitimate "no shipping profile configured" store? If so, this now shows the failure/retry UI and clears the applied shipping line instead of "No shipping methods found." A quick confirmation either way is enough.
| if (deliveryMethod === DeliveryMethods.SHIP && hasShippingDestination) { | ||
| const previousShippingMethods = shippingMethodsQuery.data ?? []; | ||
| const { data, isError } = await shippingMethodsQuery.refetch(); | ||
| const refreshedMethods = isError ? [] : (data ?? []); |
There was a problem hiding this comment.
Non-blocking design note. A transient rates failure during coupon apply now clears the order's shipping line and, on recovery, defaults to the cheapest method (the ['error', 'default'] case documents Express → Standard). On main, coupon apply had no dependency on the rates endpoint at all.
It's a deliberate choice and it's tested, so I won't block on it, but please add a short comment here explaining why clearing is preferred over keeping the line and blocking payment until retry. A cheap improvement: don't clear form.shippingMethod in this path, so recovery re-applies the customer's method instead of the cheapest.
| offline: '', | ||
| mercadopago: | ||
| 'Verwende das MercadoPago-Formular unten, um deinen Kauf sicher abzuschließen.', | ||
| 'Verwenden Sie das MercadoPago-Formular unten, um Ihren Kauf sicher abzuschließen.', |
There was a problem hiding this comment.
Nit: this MercadoPago string change (du → Sie) is unrelated to the PR. Fine to keep, but please mention it in the changeset or split it out.
| } | ||
| } | ||
|
|
||
| return Array.from(codes).sort(); |
There was a problem hiding this comment.
Nit (non-blocking): useApplyShippingMethod now passes codes in sorted order, whereas main sent order-level then shipping-level. Harmless if the discount API is order-insensitive; flagging in case it isn't.
…etch An automatic shipping pick is never customer-edited, so form hydration could reset it to the order's previous method while the apply was in flight, making the form and order alternate between two methods indefinitely. For automatic picks, the order's shipping line now takes precedence over the form. Remove the GoDaddy wallet's shipping refetch on coupon changes: rates do not depend on wallet-entered coupons, and replacing the wallet's methods could desync the selected method from the charged shipping amount. Keep discount codes in insertion order, explain why a failed rate refresh clears shipping, and note the German copy change in the changeset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wcole1-godaddy
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. The loop fix is the right shape: with getCurrentShippingServiceCode the order's line outranks the form for automatic picks, and it's applied in both shipping-method.tsx and useReconcileAfterDiscount. I confirmed the new regression test fails (times out in the loop) without the one-liner and passes with it, and the shipping/discount suites are stable across repeated runs. The rest of round two is resolved as well: wallet shipping refetch removed, insertion order restored for discount codes, clear-on-failure documented, useRemoveShippingMethod on the core mutation, and the German copy noted in the changeset.
Typecheck, 845 tests, and biome pass locally; branch is current with main.
Approving. Three items from rounds one and two are still unanswered — a one-line reply on each before merge, please:
checkout-env.ts: theorderIdsession field and thecheckoutSession(id:)argument are still in the diff and aren't referenced by any query or mutation here. Please split them out or confirm this is an intentional schema sync.calculatedShippingRates: null: bothcalculatedShippingRatesandratesare nullable in the schema. If the API returnsnullfor a store with no shipping profile, those stores now see the "Unable to load shipping methods / Try again" state instead of "No shipping methods found." I traced the rest of the flow and it's a UX regression rather than a payment blocker, but I'd like a yes/no.- Free-shipping filter: a link to the server-side
minimumOrderTotalenforcement would close this out. Also noteCheckoutSession['experimental_rules']losesfreeShippingin a patch; the type is public.
Two nits, non-blocking:
- The changeset calls
experimental_rules.freeShipping"unused" —filterAndSortShippingMethodsconsumed it onmain. Suggest: "Remove client-side free-shipping filtering (experimental_rules.freeShipping); the shipping API is authoritative for rate eligibility." discount-standalone.tsxstill has its own inline copy of the code-collection loop;getDraftOrderDiscountCodeswould replace it.
…geset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Ensure discount changes correctly reconcile shipping rates and taxes without duplicate requests or stale totals.
Discounts can change shipping eligibility, including enabling or disabling free shipping. The checkout now refetches shipping methods after a successful discount change when a valid shipping destination is available. It determines whether the returned rates require shipping reconciliation and assigns the final tax calculation to either the discount or shipping workflow, ensuring taxes run exactly once.
This PR also improves shipping-method selection and express checkout discount synchronization.
Key changes
experimental_rules.freeShippingclient-side filtering and query fields; the shipping API is authoritative for rate eligibility.PriceAdjustmentsrequests from unrelated draft-order updates.Changeset
Test Plan
Validation completed:
pnpm --filter @godaddy/react typecheckpnpm --filter @godaddy/react test(79 test files, 845 tests passed)biome checkon changed files🤖 Generated with Claude Code